Skip to content

refactor: Build user input prompts with intent constructors - #459

Open
tiurin wants to merge 7 commits into
mainfrom
prompt-intent-constructors
Open

refactor: Build user input prompts with intent constructors#459
tiurin wants to merge 7 commits into
mainfrom
prompt-intent-constructors

Conversation

@tiurin

@tiurin tiurin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Motivation

#457 and #458 each fixed one prompt that had shipped with the wrong layout, and the review comment on #457 wrote down the rule they were both applying:

  • Inline Y/n for binary confirmations: "Remove snapshot? [y/N]"
  • Vertical choices for distinct actions: "Keep waiting", "Stop and exit", etc.

That rule is not written anywhere. UserInputRequestEvent asked its author for a Vertical bool, which is a rendering decision. It is easy to leave the field out which defaults to inline.

Also, labels had drifted into three styles at once: [ENTER] Log in again, Update now [U], and a bare AWS advertising no key at all.

Solution

Give user input a vocabulary of intents and derive the layout from it. User promts can be built using one of the new constructors in internal/output/prompt.go. The developer uses the appropriate prompt type which automatically applies the design rule :

Constructor What it is Rendering
output.Confirm(prompt, DefaultYes|DefaultNo, ch) y/n on an action the user already requested inline [y/N], capitalized answer is what ENTER picks
output.ActionChoice(prompt, options, ch) a choice between distinct outcomes one selectable row per option
output.Acknowledge(prompt, label, ch) a single keypress, no choice inline

UserInputRequestEvent can't be used directly, but can be easily extended with a new prompt type if needed. See technical details:

Technical details

To enforce the usage of constructors, UserInputRequestEvent's fields are unexported - this is done by renaming variables from uppercase to lowercase and read-only accessors are added. This way a struct literal built anywhere outside internal/output does not compile:

internal/reset/reset.go:43:36: unknown field Prompt in struct literal of type
output.UserInputRequestEvent, but does have unexported prompt

Unexporting only Vertical would not have worked — Go lets a keyed literal from another package omit unexported fields, so the bypass would still compile and would still default to the inline layout that caused the bug. Removing every exported field is what closes it. UserInputRequestEvent{} remains legal but inert: no prompt, nil channel, nothing settable.

Behavior changes

The default has been changed to No for lstk snapshot remove. Rationale - other destructive operations, e.g. lstk reset, lstk volume clear, have No as a default - meaning that accidentally pressing Enter does not perform an operation that cannot be undone. I've made snapshot remove consistent with the rest. See comments in review below: #459 (comment)

Review

I've reviewed the code and tested the behaviour manually. I've also shortened generated comments and claude instructions.

I've added a self-review with examples of inconsistencies that this PR fixes and prevents in the future.

Validation

  • go test ./internal/... ./cmd/... -count=1
  • golangci-lint run ./... — 0 issues
  • make build
  • Seal proved by adding a raw literal to internal/reset/reset.go and confirming go build ./... rejects it

Follow-up to #457 and #458.

Closes DEVX-1057

@tiurin tiurin added semver: patch docs: needed Pull request requires documentation updates labels Aug 13, 2026
@tiurin tiurin changed the title Build prompts from intent constructors instead of a layout flag refactor: Build prompts from intent constructors instead of a layout flag Aug 13, 2026
@tiurin tiurin added docs: skip Pull request does not require documentation changes and removed docs: needed Pull request requires documentation updates labels Aug 13, 2026
@tiurin
tiurin force-pushed the prompt-intent-constructors branch 2 times, most recently from 8ea1c73 to d063210 Compare August 17, 2026 13:57

@tiurin tiurin left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added examples of inconsistencies that are fixed and should be prevented in the future by this refactoring.

Comment on lines -1632 to -1640
m.sink.Emit(output.UserInputRequestEvent{
Prompt: "LocalStack is still starting. Check progress with 'lstk logs'.",
Options: []output.InputOption{
{Key: "w", Label: "[W] Keep waiting"},
{Key: "s", Label: "[S] Stop and exit"},
m.sink.Emit(output.ActionChoice(
"LocalStack is still starting. Check progress with 'lstk logs'.",
[]output.InputOption{
{Key: "w", Label: "Keep waiting"},
{Key: "s", Label: "Stop and exit"},
},
ResponseCh: responseCh,
Vertical: true,
})

@tiurin tiurin Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shows well the key benefits of the change.

  • name talks about a specific intent
  • rendering details that are easy to miss (Vertical) are hidden behind ActionChoice implementation.
  • Key rendering like [W] is derived from the key itself, doesn't leave a place for drift

Comment thread internal/reset/reset.go
Prompt: "Reset emulator state? All resources will be lost",
Options: []output.InputOption{
{Key: "y", Label: "Yes"},
{Key: "n", Label: "NO"},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was inconsistent with the rest of y/n prompts: Yes/NO here, y/N everywhere else.

responseCh := make(chan output.InputResponse, 1)
sink.Emit(output.UserInputRequestEvent{
Prompt: "Which emulator would you like to use?",
Options: options,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actual shortcut keys to select the emulators were not displayed because ShortName() didn't contain the text:

Image

It's hard to understand while writing the code because InputOption receives both key and label, so agents often assume label doesn't need to contain the key shortcut prefix in their text.

With the refactoring the rendering is left to the ActionChoice implementation and is consistent between usages - shortcut keys are always displayed if provided. prevents the problem and keeps ShortName() clear and without a possibility to drift from defined shortcut key to what is typed in the label

Comment thread internal/update/notify.go
responseCh := make(chan output.InputResponse, 1)
sink.Emit(output.UserInputRequestEvent{
Prompt: "Update lstk to latest version?",
Options: []output.InputOption{{Key: "u", Label: "Update now [U]"}, {Key: "r", Label: "Remind me next time [R]"}, {Key: "s", Label: "Skip this version [S]"}},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here key shortcut was printed as a suffix (Update now [U]), while in every other user prompt it is a prefix (e.g. [R] Re-authenticate).

Leaving rendering the shortcuts to ActionChoice implementation rather than typing it out in a label string keeps the formatting consistent across CLI.

Comment thread internal/output/events.go
Options []InputOption
ResponseCh chan<- InputResponse
Vertical bool
prompt string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping fields package-private by using lowercase names. This means UserInputRequestEvent can't be meaningfully instantiated directly. prompt.go is in the same package so new constructors can set these fields when they build an instance.

})
sink.Emit(output.Confirm(
fmt.Sprintf("Delete cloud snapshot 'pod:%s'? This operation cannot be undone.", podName),
output.DefaultNo,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a behaviour change here - the default is changed to No. Rationale - other destructive operations, e.g. reset emulator, clear volume, have No as a default - meaning that accidentally pressing Enter does not perform an operation that cannot be undone. I've made snapshot remove consistent with the rest here - @anisaoshafi would love to hear your opinion since you worked on snapshots recently IIRC.

Comment thread internal/output/events.go
vertical bool
}

func (e UserInputRequestEvent) Prompt() string { return e.prompt }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding accessors for fields that are package-private now - they need to be read by rendering code in other packages, e.g. in app.go.

@tiurin
tiurin force-pushed the prompt-intent-constructors branch from f07e12d to 49745bc Compare August 18, 2026 14:07
@tiurin
tiurin marked this pull request as ready for review August 18, 2026 14:11
@tiurin
tiurin requested review from a team and peter-smith-phd as code owners August 18, 2026 14:11
@tiurin
tiurin requested a review from gtsiolis August 18, 2026 14:12
@tiurin tiurin changed the title refactor: Build prompts from intent constructors instead of a layout flag refactor: Build user input prompts from intent constructors Aug 18, 2026
@tiurin tiurin changed the title refactor: Build user input prompts from intent constructors refactor: Build user input prompts with intent constructors Aug 18, 2026

@gtsiolis gtsiolis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean refactor moving UserInputRequestEvent construction behind three intent constructors (Confirm/ActionChoice/Acknowledge) with private fields, migrating every call site and its tests, and centralizing shortcut derivation in OptionLabel.

  1. thought(non-blocking): on internal/snapshot/remove.go — this quietly changes user-facing behavior: the old delete prompt used labels {Y, n}, so ENTER confirmed the delete, whereas Confirm(DefaultNo) makes ENTER cancel it. That's strictly safer for an irreversible op and is covered by the new [y/N] assertion, but a behavior change riding inside a "refactor" PR is easy to miss — worth a line in the description so reviewers know it's intentional. (Same applies to reset/volume clear, though those already defaulted to No.)
  2. praise: the coverage migration is careful — the app-level tests that could only be built from hand-rolled option shapes are removed, and their rules (explicit-enter priority over an uppercase default, non-letter labels, any-key priority) are already exercised directly against resolveOption in TestResolveOption, so nothing is actually lost.

Automated review on behalf of @gtsiolis.


Generated by Claude Code

@tiurin

tiurin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author
  • thought(non-blocking): on internal/snapshot/remove.go — this quietly changes user-facing behavior: the old delete prompt used labels {Y, n}, so ENTER confirmed the delete, whereas Confirm(DefaultNo) makes ENTER cancel it. That's strictly safer for an irreversible op and is covered by the new [y/N] assertion, but a behavior change riding inside a "refactor" PR is easy to miss — worth a line in the description so reviewers know it's intentional. (Same applies to reset/volume clear, though those already defaulted to No.)

👍 This change was already mentioned in the self-review: #459 (comment). But I agree it is worth highlighting in the PR description. Added.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs: skip Pull request does not require documentation changes semver: patch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants